結論先說:好的 SLI 靠近使用者結果、能驅動動作,且不需要為了好看排除所有麻煩資料;FastAPI 只是產生事件的地方,不會替你選指標。這篇(上)先把「什麼叫成功」寫成一份能被反駁的規格,再落到事件模型與 middleware 實作;怎麼把規格轉成查詢、怎麼驗證規格跟程式碼行為一致,留給(下)。
前幾天的 Lab 已經把 Prometheus、Loki、Tempo 接好了,今天要處理的不是「怎麼接監控系統」,而是「接上去之後,那些數字到底該不該代表使用者的體驗」。這個問題沒有現成公式,只能一格一格審。
對 answer API,可以先寫三個可討論的候選:
| 使用者期待 | SLI | 常見誤區 |
|---|---|---|
| 得到可用回覆 | good outcome ratio | 只數 HTTP 2xx |
| 不要等太久 | 在門檻內完成的比例 | 只看平均 latency |
| 依賴故障不拖垮我 | dependency timeout ratio | 把 provider dashboard 當本服務 SLI |
第三項可作 diagnosis SLI,也可在使用者確實受影響時納入主要 SLO。不要因為下游不是自己維護就假裝它不影響你。
這句話拆開來看,其實在講兩個不同的情境,兩者的指標角色也不同。
情境 A:有 fallback,且 fallback 品質可接受
llm_provider timeout → 自動切換 fallback_provider → 使用者仍拿到答案
dependency timeout ratio 上升,但 availability 沒有下降
→ dependency 停留在 diagnosis SLI,本身不對外承諾
情境 B:沒有 fallback,或 fallback 品質不可接受
llm_provider timeout → 直接回錯誤給使用者
dependency timeout ratio 上升,availability 跟著下降
→ 這時候「依賴故障不拖垮我」這句使用者期待已經沒有兌現
→ dependency 的惡化必須直接反映在 availability SLI 上,
不能只留在後台當診斷用途
決定 dependency 是留在診斷層、還是要拉進主要 SLO,關鍵不是「這是不是我自己維護的服務」,是「這個依賴故障時,使用者最終有沒有拿到能用的結果」。把 dependency 藏在診斷層、只給 availability 好看的數字,等於是在假裝一個真實存在的失敗模式不存在。
為什麼不能只看平均值? Google 在《The Tail at Scale》裡說明過,分散式系統只要有一小部分元件變慢,整體服務被拖慢的機率就會被放大,因為使用者的一次請求往往要等最慢的那個子系統。AWS 也公開表示,Lambda 的 cold start 只發生在不到 1% 的呼叫,但單次冷啟動足以讓那次呼叫的延遲從毫秒級跳到數秒,若只看平均值,這條尾巴完全看不到。平均值會掩蓋這些尾部現象。
Google SRE Book 提出的 Four Golden Signals(latency、traffic、errors、saturation)與後來業界常說的 RED(rate、errors、duration)幾乎是每個監控課程的第一張投影片。它們好用,也正因為好用,很容易被誤會成「把這四個字套進系統就等於有了 SLI」。
問題在於,這兩套框架描述的是「服務層」要觀察的維度,不是「這個服務對使用者的承諾是什麼」。同一個 latency 維度,在 /ask 這個 endpoint 上可以是「使用者等回答的時間」,也可以是「health check 探測的往返時間」——兩者的 P95 曲線可能長得完全不像,但如果 dashboard 上都叫 latency,值班者看到紅燈時第一件事還是得先猜這條線在講哪個。
Golden Signals / RED
│ latency / errors / traffic / saturation
│ 這是「要往哪裡看」的清單
▼
SLI
│ good_when / eligible_when 的具體規格
│ 這是「看到什麼算達標」的承諾
▼
SLO
承諾要維持在什麼比例、多長時間窗
RED 和 Golden Signals 告訴你「該往哪個維度看」,但不會替你回答「這個維度上,什麼結果對使用者是好的」。那一步永遠要回到① 的使用者句子,沒有框架能跳過它。
誤解一:SLI 越多越好。 每加一個 SLI,就多一份需要有人看、有人在紅燈時知道該做什麼的承諾。一個服務若同時對外宣稱十幾個 SLO,值班者半夜收到告警時,往往花在「這個 SLO 到底代表什麼」的時間比花在修復上還多。Day 17 只挑三個候選(availability、latency、dependency),不是因為系統只有三種行為,而是這三個已經足以覆蓋「使用者句子」列出的三個期待,其餘先留在 log 與 trace 裡當診斷資料。
誤解二:SLI 應該用監控系統現成提供的指標。 Prometheus、CloudWatch 或雲端供應商的儀表板通常會預先生成一批「看起來像 SLI」的圖表——CPU、記憶體、連線數、5xx 比例。這些是基礎設施在說它自己的狀態,不是使用者在說他們的體驗。把現成指標直接拿來當 SLO,等於讓 infra 團隊的方便,取代了使用者的期待。
誤解三:下游依賴也該有自己對外的 SLO。 LLM provider、向量資料庫、第三方 API,這些依賴值得被監控,但它們的健康狀態是 diagnosis 訊號,不是這個服務向使用者承諾的東西。除非依賴故障必然等於使用者失敗(沒有任何 fallback),否則它該進 dependency SLI,而不是直接冒充 availability SLI。
在 response middleware 或 handler 結束時記錄一筆 structured event。不要把 prompt 或 user ID 當 metric label。
event = {
"route": "/ask",
"status": outcome,
"latency_ms": elapsed_ms,
"dependency": "llm_provider",
"dependency_outcome": dependency_outcome,
}
真正接入監控前,先把 event schema 與 SLO spec 放在一起審查。日後 query 改了,才不會發現分子和原來承諾根本不同。
實踐建議:這裡的 latency_ms 對應的是「request 完成時間」,但如果系統是 streaming(如 LLM 回答),需要額外記錄 TTFT(Time to First Token)。同樣的「P99 latency」在不同系統可能指完全不同的東西。前一節提到的尾端放大現象也適用在多層架構:下層的 P95 疊加起來,很容易變成上層的 median。所以 event schema 應該保留足夠的細節(例如分別記錄「queue time」、「process time」、「io time」),而不是單一的 latency_ms,這樣日後才能診斷瓶頸。
把一次 /ask 請求的生命週期拆開來看,會更清楚為什麼單一 latency_ms 不夠用。
request 進來
│
├─ queue_time 等待 worker 有空處理(負載高時才明顯)
│
├─ process_time │─ validation
│ │─ prompt 組裝
│ │─ 呼叫 llm_provider(通常是最大宗)
│ └─ 回應序列化
│
└─ io_time 寫 log、送 metric、trace exporter 的額外開銷
latency_ms 是這四段時間加總後的結果,但值班者看到 latency SLI 變壞時,真正想知道的往往是「哪一段變長了」。如果 queue_time 突然拉長,代表 worker 數量不足或流量突增;如果 process_time 裡呼叫 provider 那一段變長,代表下游變慢;如果 io_time 變長,代表可觀測性系統本身可能出了問題(這種情況特別諷刺——監控系統反過來拖慢了它要監控的服務)。保留這四個欄位,日後才不用每次都靠猜。
為什麼不能把 user_id 當 label:Day 2 提過 Honeycomb 的 Charity Majors 觀察到的真實案例——團隊把高基數欄位塞進 metric label,time series 資料庫的 cardinality 直接爆炸,每月監控帳單多出數萬美元。同樣的機制放在 user_id 上更危險:使用者數量本來就沒有上限,一旦當成 label,時間序列數量會跟著使用者數線性成長,直到 Prometheus 記憶體耗盡、查詢全面變慢。而且這個過程通常是無聲的,沒有告警、沒有錯誤碼,監控系統只是逐步喪失功能,等到有人發現時已經是連鎖故障。這正是 Day17 ④ 節的規則不是建議、而是不可妥協的設計原則的原因。
一個常見的混淆是把「structured event」當成第四種可觀測性資料類型,事實上它是 metric 與 log 之間的原料。同一筆 event dict,一部分欄位(route、status、dependency_outcome)被聚合成低基數的 Prometheus counter,另一部分欄位(完整的錯誤訊息、request_id)被原樣寫進結構化 log。兩條資料流從同一個事件分岔,但終點的責任不同。
┌──> 聚合欄位(route/status/…)──> Prometheus counter/histogram
一次 request 的 event ─┤
└──> 完整欄位(error message/request_id/…)──> structured log line
如果把這兩條路混在一起,例如把 request_id 丟進 Prometheus label,或把完整錯誤堆疊塞進 metric,metric 端會發生 cardinality 爆炸,log 端也會失去 debug 所需的細節。這就是 Day 2 建立 Loki + Prometheus + Tempo 三件套時,讓三個系統各司其職的原因:metric 回答「多少」,log 回答「哪一筆、為什麼」,trace 回答「時間軸上跨了哪些元件」。Day 17 的 event 只負責定義三個系統共用的「原始事實」,讓後續每一層都使用同一份資料。
團隊常見的順序是先把 Prometheus、Grafana 接起來,再回頭想「SLI 應該長什麼樣」。這個順序看起來省事,實際上容易讓 SLI 的定義被監控系統已經產生的欄位綁架——因為改一個已經上線、有歷史資料的 metric schema,比改一份還沒發布的規格文件痛苦得多。一旦某個 dashboard 已經用 http_status 當作唯一分類欄位跑了半年,之後想拆出 technical_status 來區分 timeout 與 validation error,得同時處理歷史資料不相容的問題。
Day 17 建議反過來:先把 event schema 與 SLI 規格一起審查(下一節會示範),確認 good/eligible 的定義站得住腳,再讓 metric 與 dashboard 照著這份規格產生,而不是照著 metric 現成長出來的欄位回頭定義「什麼叫成功」。
SLI 的麻煩不在除法。
麻煩在分子裡那個 good 到底是什麼。
如果 definition 沒寫清楚,Grafana 上再漂亮的 99.95%,也只是把模糊放大到電視牆尺寸。
先替 /ask 寫一份很小的規格。
service: policy-answer-api
window: rolling-28d
indicators:
availability:
event: completed_request
good_when: technical_status == "success"
eligible_when: route == "/ask" and client_cancelled == false
latency:
event: completed_request
good_when: technical_status == "success" and latency_ms <= 3000
eligible_when: route == "/ask" and client_cancelled == false
dependency:
event: dependency_call
good_when: dependency_outcome in ["success", "fallback_success"]
eligible_when: dependency == "llm_provider"
這不是可直接套用到 production 的標準答案。
3000 只是本文實驗用的候選門檻。
真正的門檻要由使用者期待、既有分布、產品情境與成本共同決定。
例如內部後台的「產生週報」可接受十秒。
聊天式客服若三秒還沒有第一個字,使用者已經開始重按送出。
同一個數字放錯場景,就只是在精確地做錯事。
寫規格前,先確認自己不是在憑空選數字,而是在描述一個已經觀察過的分布——沒有 baseline 就先訂門檻,等於是在替一個還不認識的系統下承諾。
寫 SLI 規格時,最容易滑進的陷阱是寫出一段「聽起來很像規格,但沒人能拿它跟現實對照」的文字。比較兩種寫法。
版本 A(無法反駁):
「/ask 端點應保持高可用性,讓使用者能順利得到回答。」
版本 B(可被反駁):
good_when: technical_status == "success"
eligible_when: route == "/ask" and client_cancelled == false
版本 A 沒有錯,它只是還不是規格。它沒有講清楚一件事:明天工程師改了程式碼,讓某一類原本算失敗的請求變成成功,這算不算「維持高可用性」?沒有人能從這句話反推答案,因為它沒有給出可以重算的分子分母。版本 B 則不同——只要有一份 event log,任何人都能拿這份定義重新跑一次,算出同一個數字,也能明確指出哪一類事件被排除、為什麼。
「可被反駁」(falsifiable)這個詞借自科學方法論,但用在 SLO 文件上意思很具體:規格要能被一組已知的測試案例證明「錯」或「對」。如果團隊拿不出任何一個假設情境能讓某條規格顯得不合理,這條規格多半還太模糊,不是太完美。Day 17 ⑦ 的 DIY 步驟就是刻意在做這件事——用具體 fixture 去反駁分類規則,而不是相信規則本身寫得漂亮。
分母是 SLI 最常被偷改的地方。
一個請求沒有計入分母,通常有合理理由。
也可能只是把難看的事件藏起來。
把每一種事件攤開討論。
| 事件 | 是否進 availability 分母 | 原因 | 要另記錄什麼 |
|---|---|---|---|
| 使用者成功拿到一般回答 | 是 | 這正是服務承諾 | outcome、latency |
| 服務回 500 | 是 | 使用者沒有拿到服務 | error class |
| provider 逾時後回可用 fallback | 是 | 使用者仍得到回覆 | fallback reason |
| provider 逾時後回錯誤 | 是 | 使用者受到影響 | dependency outcome |
| 使用者主動中斷串流 | 視產品定義 | 結果未知,不能偷偷當成功 | cancellation ratio |
| health check | 否 | 不是使用者 journey | health SLI |
| 未登入被拒絕的請求 | 通常否 | 不是已授權工作流程 | auth rejection ratio |
| rate limit 被拒絕 | 視承諾定義 | 若是正常產品限制可分開;若誤傷合法使用者則需納入 | throttled request ratio |
這張表的重點不是把所有格子填「是」。
重點是每一格都能說明。
寫不出理由的格子,先留白,不要先填答案。
如果團隊對「使用者取消」沒有共識,就先同時畫出 cancelled 指標。
不要在沒有證據時,急著讓它改變可用性 SLO。
一個請求要不要進分母,決策順序大致是這樣:
事件進來
│
├─ 是使用者發起、屬於承諾範圍的請求嗎?
│ 否 → 排除(health check、爬蟲、未授權存取)
│ 是 ↓
├─ 使用者完成流程了嗎?(沒被自己取消/沒被使用者放棄)
│ 否,且原因不明 → 另記 cancellation 指標,暫不動搖 availability
│ 是 ↓
└─ 技術上算 good 嗎?(technical_status == success)
是 → good
否 → bad,但仍計入分母
分母的篩選只問「這是不是一筆需要對使用者負責的請求」;good/bad 的判斷才問「有沒有做到承諾」。把這兩層混在一起做,最常見的後果就是把「難看的事件」直接從分母拿掉,讓 SLO 好看,但使用者的真實體驗完全沒變。
一個真實案例:當「可用」與「正確」互相衝突。2018 年 10 月 21 日 UTC 22:52,GitHub 兩個資料中心之間的網路連線中斷了 43 秒,這短短 43 秒讓 MySQL 的 orchestrator 自動判定主節點故障,把寫入權限切換到西岸一個還沒同步完東岸最新寫入的副本。網路恢復後,兩邊資料庫各自留著一段對方沒有的紀錄。GitHub 面對的正是本節在講的 eligible event 難題:讓服務繼續完整回應、但可能寫進不一致的資料;還是主動降級部分寫入功能,換取不再產生更多衝突。GitHub 選擇後者——webhook 派送、Issues 與 Pull Request 的部分寫入功能被降級或暫停,直到隔日資料重新協調完成,整起降級狀態長達 24 小時 11 分鐘,但多數時間 HTTP 層仍持續回應 200,不是整站 502。
如果 eligible event 表格只看 HTTP status,這 24 小時的絕大部分會被算成「可用」,但使用者實際上拿不到完整功能。根因也不是某個元件掛掉,而是一次短暫的網路分區,加上自動容錯機制在錯誤的時間點做出了正確邏輯、卻不正確時機的決策。這提醒我們:eligible/good 的定義不能只針對「元件故障」這種明顯情境設計,也要涵蓋「系統技術上還活著,但已經違反使用者期待」的灰色地帶——這正是 availability SLI 存在的意義,而不只是一個 uptime 計數器。
表格裡「rate limit 被拒絕」那一格寫著「視承諾定義」,聽起來像在迴避問題,實際上是因為這格答案真的要看數字才能判斷,抽象討論容易各說各話。拿一組具體情境試算一次。
情境:/ask 設定 rate limit 為每使用者每分鐘 10 次請求
情況 A:限流精準擋住濫用流量
總請求:10,000 次/小時
被 429 擋下:120 次(來自 3 個明顯異常的帳號,每分鐘打超過 50 次)
→ 這 120 次不是「正常使用者需求」,排除於分母合理
情況 B:一次上游變更誤把 rate limit 閾值改太低
總請求:10,000 次/小時
被 429 擋下:2,400 次(分散在數百個帳號,多數只是正常互動節奏稍快)
→ 這 2,400 次是「服務把正常使用者擋在門外」
→ 若排除於分母,availability 依然亮綠燈,
但接近四分之一的使用者其實拿不到服務
同一個 HTTP 429,情況 A 排除是對的,情況 B 排除等於把一場真正的事故藏進「不算」的分類裡。差別不在 HTTP status code,在於「被擋下的請求,分布特徵像不像正常使用模式」。這也是為什麼「throttled request ratio」值得單獨留一條指標——不是為了它自己成為 SLO,而是讓值班者能快速判斷這次 429 暴增,究竟是防禦機制正常運作,還是防禦機制本身出了問題。
Day 7 已經談過 HTTP 200 不保證答案正確。
所以 Day 17 的 availability SLI 先回答一個狹窄問題:請求有沒有以系統定義的技術方式完成?
它不宣稱回答事實正確。
它也不把安全拒答直接當故障。
以下四個結果需要拆開。
| HTTP | technical status | task status | 對 availability 的候選處理 |
|---|---|---|---|
| 200 | success | completed | good |
| 200 | success | safely_refused | 依產品契約,常為 good |
| 200 | success | incorrect_fact | 暫不混入;Day 19 的 quality SLO 處理 |
| 503 | dependency_timeout | failed | bad |
把 incorrect_fact 排除於 technical availability,並不是忽略它。
恰好相反:這是在保留可診斷性。
若一個數字同時混入 timeout、幻覺、拒答政策與使用者改變主意,告警後沒有人知道該找後端、模型、內容團隊還是產品經理。
表格裡 safely_refused 被標成「依產品契約,常為 good」,這句話值得多說一點,因為它是最容易在團隊內部吵起來的一格。安全拒答(例如系統判斷這個問題涉及需要人工審核的醫療或法律建議,主動回覆「這個問題建議諮詢專業人士」)從 technical availability 的角度看,是系統正確執行了設計好的行為——它沒有 timeout,沒有 500,甚至沒有輸出格式錯誤,它做了它被設計要做的事。但從使用者的角度看,他沒有得到原本想要的答案。
這兩種觀點都對,衝突的根源在於「使用者原本要什麼」跟「系統該不該給」本來就可能不一致。Day 17 選擇讓 safely_refused 預設落在 availability good 這一邊,理由是:如果每次安全拒答都讓 availability SLO 往下掉,等於在用可靠性指標懲罰一個刻意設計的安全機制,團隊可能因此有誘因調鬆拒答門檻換取好看的 SLO 數字,這是比「拒答率高」更危險的後果。但這不代表拒答率不重要——它該是另一個獨立追蹤的比率(safe_refusal_rate),讓產品team能單獨盯著它有沒有異常升高,而不是把它跟「系統有沒有正常運作」焊死在同一個數字上。
一種常見的監控盲點:技術指標「看起來正常」,使用者卻卡在結帳頁。這不是假設情境。OneUptime 在整理 availability SLI 常見錯誤時記錄過一個真實的電商案例:團隊的 API Gateway latency dashboard 看起來完美——平均 45ms,P95 200ms——但客服端持續收到「結帳流程卡住」的投訴。追查後才發現,資料庫連線池在尖峰流量下耗盡,handler 開始快速回傳 HTTP 503。問題是,load balancer 的延遲指標只量「回應得快不快」,而 503 本身就是一個很快的回應:伺服器幾乎立刻判斷「我沒有可用連線」,然後立刻回絕。於是延遲數字完全正常,dashboard 一片綠燈,真正拿不到服務的使用者卻完全沒有被計入任何一個紅色警訊。
這個案例精準對應本節前面「availability 與 quality 不該混成一個數字」的另一面:不是把太多語意混進一個指標,而是選錯了指標本身,讓它量到的東西跟使用者實際遭遇的完全不同層。load balancer 延遲回答的是「網路層有沒有塞車」,不是「使用者的請求有沒有被完成」。這正是為什麼 Day 17 一直堅持要先問①的使用者句子:如果一開始就問「使用者得到可用回覆了嗎」,503 這種快速失敗絕對不會被算進「服務健康」;但如果監控系統是先接上現成的 load balancer 儀表板、再回頭想 SLI,這種偽陽性的健康假象就很容易被忽略,直到客訴量大到瞞不住為止。
替每個候選 SLI 問四個問題。
1. 使用者真的會感覺到它嗎?
2. 數字變壞時,值班者知道先看哪裡嗎?
3. 分子與分母能穩定地從原始事件重算嗎?
4. 團隊願意因為它而暫停功能發布或排優先順序嗎?
第四題通常最殘酷。
沒有人會因為 CPU average 85% 而調整 roadmap,那它多半是診斷訊號,不是使用者 SLI。
CPU 仍然值得監控。
只是別讓它冒充使用者感受。
把四個問題套回本文的三個候選 SLI,結果不是三個都滿分。
候選 SLI 問題1 感受得到 問題2 值班定位 問題3 可重算 問題4 值得暫停發布
──────────────────────────────────────────────────────────
availability 是 是 是 是
latency 是 是 是 視情況
dependency(診斷用) 否 是 是 否(本身不是 SLO)
CPU 使用率 否 部分 是 否
dependency 那一行的答案不是全部打勾,這正是它一開始就被定位成「診斷訊號」而非「對外承諾」的原因——它能幫值班者定位問題(問題2),但使用者感受不到它(問題1),團隊也不會因為它單獨暫停發布(問題4)。CPU 使用率則連「值班定位」都只能打「部分」——它能提示資源緊繃,但無法直接對應到哪一類使用者請求受影響,這也是為什麼 Slack 事故裡,CPU 這個連定位能力都有限的訊號,一旦被直接接上自動化決策,後果會這麼難預料。
用錯誤的代理指標做自動化決策,代價可能是全站中斷。Slack 在 2021 年 1 月 4 日的大規模中斷,官方工程部落格點出一個關鍵環節:他們的 web tier 靠 CPU 使用率驅動 autoscaling,CPU 高就加機器,CPU 低就減機器。事故當天是新年假期後第一個工作日,大量使用者同時重新打開 Slack,流量衝到接近史上最高,同一時間 AWS 的 Transit Gateway 開始大量丟包。丟包讓許多請求卡在等網路 I/O,process 大量時間花在「等待」而不是「運算」,CPU 使用率因此不升反降。autoscaler 看到 CPU 變低,判斷「流量變少了」,主動把 web tier 縮編。容量被自己的自動化邏輯砍掉,疊加原本的網路問題,中斷進一步惡化。這正是「第四題」的活教材:CPU 從來不是使用者感受得到的東西,但當它被接上自動化決策,一個被誤讀的代理指標不只是不準的儀表板,而是直接觸發了錯誤的系統行為。
那「CPU 不能當 SLI」是不是代表 CPU 這類基礎設施指標沒有用? 不是。Slack 的教訓是「CPU 不該被拿去冒充使用者感受、或直接接上自動化決策」,不是「CPU 沒有診斷價值」。Google 公開的 Borg 論文提供了一個對照組:Borg 把任務分成服務延遲敏感的長駐服務(例如 Gmail、Docs 的後端 API)與對短期波動不敏感的批次任務,沒有直接拿「CPU 使用率」當服務健康的訊號,而是另外定義了 scheduling delay:一個可執行的 thread 等待超過一毫秒才真正搶到 CPU 的頻率。論文記載,即使叢集整體 CPU 利用率超過 80%,靠著優先權與 admission control(高優先權服務保留 CPU 資源、負載尖峰時主動拒絕新的低優先權批次任務),serving tasks 的 scheduling delay 仍能維持在「只有個位數百分比的時間等待超過 5 毫秒」。
兩個案例的差別很清楚:Slack 把 CPU 使用率直接接上 autoscaling 決策,代理指標被誤讀時沒有人類把關;Borg 選的訊號(scheduling delay)更貼近「排隊等 CPU」的實際現象,而且疊加了優先權隔離與 admission control 當防線。這給「第四題」一個更務實的延伸:診斷訊號不是不能自動化,但選訊號時要問它跟使用者體驗之間的因果關係有多直接,而不是它取得起來有多方便。
計數器只能回答「現在累積多少」。
結構化事件與 trace 才能回答「剛才哪一類請求壞了」。
兩者一起保留,但責任不同。
request
│
├── application log:帶 request_id 的細節與錯誤原因
├── trace:跨 middleware、retriever、provider 的時間脈絡
└── metric:低基數、可聚合的 SLI 計數
Prometheus label 不要放 request_id。
每個 request 都有不同值,會產生高 cardinality time series。
要找單筆請求時,使用 log 或 trace 的 request_id。
metric 只保留能有限枚舉、能聚合的分類。
下面是可用於文章 Lab 的事件資料類別。
from dataclasses import asdict, dataclass
from time import perf_counter
from typing import Literal
TechnicalStatus = Literal[
"success",
"dependency_timeout",
"dependency_error",
"validation_error",
"internal_error",
]
@dataclass(frozen=True)
class RequestOutcome:
route: str
technical_status: TechnicalStatus
latency_ms: float
dependency_outcome: str
workflow_version: str
client_cancelled: bool = False
def as_log_fields(self) -> dict[str, object]:
return asdict(self)
frozen=True 的 dataclass,不是普通 dictRequestOutcome 選擇了 @dataclass(frozen=True),這個選擇不是風格偏好,是在替事件資料的完整性把關。
用 dict:
event["technical_status"] = "success"
# 任何一段程式碼、任何一層 middleware,
# 都可以在事件產生後偷偷改掉這個值
# 而且 typo(例如 event["tecnical_status"])不會有任何錯誤
用 frozen dataclass:
outcome = RequestOutcome(route="/ask", technical_status="success", ...)
outcome.technical_status = "failure" # AttributeError,直接炸掉
# typo 在建構時就會被型別檢查工具抓到
一個事件一旦被建立,理論上不該再被任何後續程式碼修改——它代表的是「這次請求發生時的事實」,不是一個可以隨時被覆寫的可變狀態。frozen=True 把這個假設寫進了型別系統,而不是只寫在文件裡靠開發者自律遵守。搭配 Literal 型別限定 TechnicalStatus 只能是五種預先定義的字串,這兩個選擇合起來,讓「亂塞一個沒在 taxonomy 裡的字串」這種錯誤,在寫程式的當下就被 IDE 或型別檢查器擋下,而不是等到上線後 Prometheus label 多出一個誰也沒看過的值才發現。
workflow_version 也不是 metric label 的唯一答案。
若版本會跟每次 commit 一起變,時間序列數量仍會一直長。
對 metrics,較安全的作法是只用受控的 release channel,例如 stable、canary。
完整 git SHA 留在 log、trace 或 deployment metadata。
這個「同一個屬性,去不同地方」的思路,其實跟 Day 2 建立 OpenTelemetry 這套 observability stack 時,span attribute 的分層原則完全一致——OTel semantic conventions 也區分「該放進 span attribute(可以是高基數、因為 trace 儲存與查詢方式不同)」與「該聚合成 metric」的欄位。Day 17 的 RequestOutcome 只是把同一個分層原則,套在自己定義的 event 上:
release_channel → metric label(低基數、受控枚舉)
→ 同時也可以是 trace 的 span attribute
git_sha → 只進 trace attribute 或 log field
(高基數,但 trace/log 的儲存與 retention 策略
本來就設計來承受這種基數)
request_id → 只進 trace/log,絕不進 metric
三個系統對「基數」的容忍度不同,不是巧合,是 Day 2 選擇 Prometheus + Loki + Tempo 三件套時,各自技術特性決定的:Prometheus 的 time series 資料庫結構讓高基數 label 直接反映成記憶體與查詢成本;Loki 的 index 設計只對 label 建索引、log 內容本身用全文搜尋,所以能承受更高基數的欄位值;Tempo 則是以 trace ID 為主鍵直接查詢,天生就是為高基數的單筆請求追蹤設計的。理解這三者的基數容忍度差異,才知道同一個欄位該往哪裡放,而不是每次都靠直覺猜。
第一次設計常犯的錯是枚舉得太細。
例如把每個 provider HTTP code、每個 parser exception、每個模型名稱都塞進 outcome。
這會讓 dashboard 的分母很難看,告警也很難讀。
先選能回答值班問題的分類。
success
dependency_timeout
dependency_error
validation_error
internal_error
收到事件後,再在 log 中加較細的 error_code。
例如 provider_429、provider_503、json_decode_error。
這樣 alert 看的是五種穩定類別。
debug 時仍能追到具體原因。
taxonomy 一旦定案,改動成本會隨時間增加,因為歷史資料是用舊分類累積的。
先花時間想清楚,比日後重新回填資料划算。
這個「先少後多」的原則,實際上是在兩種成本之間找平衡點。
分類太粗(例如只有 success / failure 兩種):
alert 簡單,但值班者收到告警後
完全不知道要往哪裡查,每次都要重新從 log 挖掘
分類太細(例如每個 provider HTTP code 都自成一類):
診斷資訊看似豐富,但:
- alert 規則要窮舉每一種 bad 分類,容易漏
- dashboard 上一堆小分類,肉眼看不出哪個在惡化
- 新增一個 provider 錯誤碼,taxonomy 就要跟著改一次
error_code 放在 log 而不是 metric taxonomy 裡,解決的正是「細節需要保留、但不該讓穩定的告警規則跟著每一種細節變動」這個矛盾。alert 只盯著五種 technical_status,觸發後才進 log 挖 error_code;細節與穩定性因此各自待在該待的位置。
下面這些欄位適合放在受存取控制的 log 或 trace attribute,前提仍是先做資料遮罩與 retention 設計。
request_id
trace_id
hashed_user_id
prompt_template_version
retrieval_index_version
provider_request_id
下面這些欄位不該成為 Prometheus label。
prompt
model_output
document_content
raw_user_id
email
request_id
trace_id
原因不只 cardinality。
還有敏感資料擴散。
一張公開 dashboard 若能用 label filter 翻出 prompt,事故會從可靠性事件升級成資安事件。
「高基數」聽起來抽象,實際換算成時間序列數量,數字會直接嚇人。Prometheus 的 time series 數量,是每個 label 可能值數量的乘積,不是相加。
time_series 數量 = label_1 的值數量 × label_2 的值數量 × … × label_n 的值數量
以本文範例的 answer_requests_total{route, technical_status, release_channel} 為例:
route: 1 種(固定為 /ask)
technical_status: 5 種(success/dependency_timeout/…)
release_channel: 2 種(stable/canary)
────────────────────────────────
time series 數量 = 1 × 5 × 2 = 10
十條時間序列,Prometheus 完全無感。現在把其中一個 label 換成 user_id:
route: 1 種
technical_status: 5 種
user_id: 假設 10 萬活躍使用者
────────────────────────────────
time series 數量 = 1 × 5 × 100,000 = 500,000
從 10 條變成 50 萬條,只因為換了一個 label。而且這個數字不會停在 50 萬——它會隨著使用者成長持續線性增加,每個新使用者的第一次請求,都在替 Prometheus 多開一條永遠不會消失的時間序列(除非設定 retention 主動清除)。OpenObserve 記錄過一個真實案例:一個團隊在請求計數器上加了 user_id label,原意只是想「照使用者拆流量」,系統當時有百萬活躍使用者,三個月內累積出超過 500 萬條時間序列。過程完全無聲——沒有一次性的錯誤或告警,metric 本身看起來也「正常」,記憶體只是逐月被啃食,直到 Prometheus 被 OOM killed。那一刻造成的傷害不只是這一個 metric 不能用,而是整個監控系統失明:Grafana dashboard 全部變空白,Prometheus 反覆重啟仍撐不住記憶體壓力,查詢從毫秒級的回應時間,變成 30 秒逾時或直接無回應。
這個案例值得記住的重點,不是「500 萬」這個數字本身,而是傷害發生的路徑:cardinality 爆炸不會先讓某個 request 失敗,它會先讓「告訴你系統有沒有故障的系統」自己先故障。等工程師發現 dashboard 打不開時,往往已經沒有足夠的歷史資料能回溯問題從哪個 commit 開始。這正是為什麼 Day 17 ④ 節把「label 只放低基數欄位」寫成不可妥協的規則——它保護的不只是這一個 metric 的查詢效能,是整個觀測系統在你最需要它的時候還活著。
FastAPI 官方文件示範可用 @app.middleware("http") 在 request 進入與 response 回傳之間執行程式,並以 call_next 交給後續 path operation。
這個位置很適合量整個 HTTP request 的耗時。
它不適合憑空判斷語意品質。
品質結果要由 handler、evaluator 或後續工作流明確傳遞。
以下範例刻意使用少量、受控的 labels。
import logging
from time import perf_counter
from fastapi import FastAPI, Request
from prometheus_client import Counter, Histogram
app = FastAPI()
logger = logging.getLogger("sli")
REQUESTS = Counter(
"answer_requests_total",
"Completed answer requests grouped by technical outcome.",
["route", "technical_status", "release_channel"],
)
LATENCY = Histogram(
"answer_request_duration_seconds",
"End-to-end HTTP request duration for the answer route.",
["route", "release_channel"],
buckets=(0.1, 0.3, 0.5, 1, 2, 3, 5, 10),
)
@app.middleware("http")
async def record_request_sli(request: Request, call_next):
started = perf_counter()
technical_status = "internal_error"
release_channel = "stable"
try:
response = await call_next(request)
technical_status = getattr(
request.state,
"technical_status",
"success" if response.status_code < 500 else "internal_error",
)
release_channel = getattr(request.state, "release_channel", "stable")
return response
except Exception:
logger.exception("unhandled request failure")
raise
finally:
elapsed_seconds = perf_counter() - started
route = request.url.path
REQUESTS.labels(route, technical_status, release_channel).inc()
LATENCY.labels(route, release_channel).observe(elapsed_seconds)
logger.info(
"request_outcome",
extra={
"route": route,
"technical_status": technical_status,
"latency_ms": round(elapsed_seconds * 1000, 2),
"release_channel": release_channel,
},
)
範例有幾個刻意保守的地方。
route 若直接取 request.url.path,遇到 /documents/{id} 這類實際 ID 路徑時仍會有 cardinality 問題。
正式服務應使用 router template、明確 allowlist,或只對固定端點量測。
/ask 本身是固定路徑,所以這個 Lab 的風險較小。
finally 確保 exception path 仍會產生 measurement。
但如果 process 在 finally 前被強制終止,事件仍可能遺失。
監控資料不是法庭記錄;它也有失敗模式。
call_next(request) 回傳的是 Starlette 組好的 Response 物件,但這不代表 body 已經完整送到使用者手上。
對一般 JSON 回應,兩者幾乎同時發生,時間差可忽略。
但對 StreamingResponse(例如逐字吐出的聊天回覆),情況不同。
call_next 通常在 response 物件準備就緒時就返回,真正把每個 chunk 寫進 socket、直到連線關閉,是 ASGI server 把 response 往下傳的過程中才完成。
middleware 的 finally 區塊這時多半早就執行完畢。
也就是說,這段 elapsed_seconds 量到的是「server 決定要開始回應要花多久」,不是「使用者等到最後一個字要花多久」。
這兩個時間在非串流 API 上幾乎相等。
套在串流回應上卻可能差到數秒。
這也是為什麼 Day 18 要另外定義 TTFT(Time to First Token)與串流完成時間。
同一段 middleware 程式碼,量到的意義會因為 response 型別而整個改變。
不能只看程式有沒有跑過,要問它量到的時間軸落在請求生命週期的哪一段。
middleware 不知道 provider 有沒有 timeout。
handler 知道。
所以 handler 要用一致的方式把分類交給 middleware。
from fastapi import HTTPException, Request
@app.post("/ask")
async def ask(question: str, request: Request) -> dict[str, str]:
request.state.release_channel = "stable"
try:
answer = await answer_policy_question(question)
except ProviderTimeoutError as exc:
request.state.technical_status = "dependency_timeout"
raise HTTPException(status_code=503, detail="temporary upstream timeout") from exc
except OutputValidationError as exc:
request.state.technical_status = "validation_error"
raise HTTPException(status_code=502, detail="invalid upstream response") from exc
except Exception:
request.state.technical_status = "internal_error"
raise
request.state.technical_status = "success"
return {"answer": answer}
這裡的 ProviderTimeoutError 與 OutputValidationError 是你自己的 domain exception。
不要把某個 SDK 的所有例外型別散落在每個 endpoint。
先在 adapter 層轉換,才有穩定的 taxonomy 可用。
「先在 adapter 層轉換」這句話說起來簡單,具體長什麼樣子值得展開一下,否則容易變成一句沒人真的照做的建議。
沒有 adapter 層(SDK 例外直接洩漏到 handler):
@app.post("/ask")
async def ask(...):
try:
answer = await provider_sdk.complete(...)
except provider_sdk.TimeoutError:
...
except provider_sdk.RateLimitError:
...
except provider_sdk.InvalidRequestError:
...
except provider_sdk.APIConnectionError:
...
# 換一家 provider,這整串 except 全部要重寫
有 adapter 層:
# adapter.py —— 只有這裡認得 provider SDK 的例外型別
async def call_llm_provider(prompt: str) -> str:
try:
return await provider_sdk.complete(prompt)
except (provider_sdk.TimeoutError,) as exc:
raise ProviderTimeoutError(str(exc)) from exc
except (provider_sdk.RateLimitError, provider_sdk.APIConnectionError) as exc:
raise ProviderError(str(exc)) from exc
# main.py —— handler 只認得穩定的 domain exception
@app.post("/ask")
async def ask(...):
try:
answer = await call_llm_provider(prompt)
except ProviderTimeoutError:
...
except ProviderError:
...
差別在於:換一家 LLM provider、或是同一家 provider 升級 SDK 版本改了例外型別,需要改的地方只剩 adapter 層那幾行。handler 裡的分類邏輯、middleware 裡的 technical_status 對應、SLO spec 裡的 taxonomy,全部維持不變,因為它們認的是 ProviderTimeoutError 這個穩定的 domain exception,不是隨 SDK 版本浮動的底層型別。這也是 Day 10 提過的 Fault → Error → Failure 鏈條在程式碼層級的具體實踐:adapter 層負責把外部世界五花八門的 Fault(各種 SDK 例外)轉換成系統內部一致的 Error 分類,讓上層不需要認得整個外部世界。
FastAPI 文件以 X-Process-Time 示範把耗時放入 response header。
本機檢查時很方便。
但 client 不一定會保存 header,proxy 也可能改寫它。
真正的 SLI 原始資料仍應由 service 端 metric、log 或 trace 產生。
response.headers["X-Process-Time-Ms"] = f"{elapsed_seconds * 1000:.1f}"
若要加這個 header,確認它不會洩漏內部拓樸、request ID 或 provider 資訊。
一個只含處理時間的數字通常風險較低。
仍要以你的 threat model 為準。
判斷一個 debug header 能不能加,可以先把它跟其他資料通道的暴露面攤開比較一次。
資料通道 暴露對象 合適放的內容
────────────────────────────────────────────────────
Response header 任何拿到 response 的人 單純的耗時數字(風險低)
Prometheus label 有查詢權限的內部人員 低基數分類值
Structured log 有 log 存取權限的人員 完整錯誤細節、hashed id
Trace attribute 有 trace 存取權限的人員 跨服務的時間脈絡
Response header 的暴露對象最寬,卻常常是最少被檢視威脅模型的一個——因為它看起來只是「多回一個欄位」。實際上任何能發出這次請求的人都拿得到它,包括第三方整合、瀏覽器開發者工具、甚至惡意探測者。這也是為什麼本文只建議放「處理耗時」這種低風險數字,而把所有可能洩漏拓樸或內部狀態的欄位,留在存取權限受控的 log 與 trace。
FastAPI 的 @app.middleware("http") 底層是 Starlette 的 ASGI middleware stack,多個 middleware 疊加時會形成一層層的洋蔥結構。後註冊的 middleware 反而包在更外層,request 進來時先經過它,response 出去時也最後經過它。
request 進入
│
▼
middleware C(最後註冊,最外層)
│
▼
middleware B
│
▼
middleware A(最早註冊,最內層)
│
▼
path operation(實際 handler)
│
▼
middleware A(response 往回走)
│
▼
middleware B
│
▼
middleware C
│
▼
response 送出
這個順序對 SLI middleware 的意義很直接:
elapsed_seconds 不含身份驗證、限流本身花掉的時間;被限流擋下的請求可能完全不會經過 SLI middleware,連「被拒絕」這件事都沒有留下紀錄。route="/ask" 分母裡也會混入被限流擋下、根本沒進到 handler 的請求。兩種順序都可能是對的,取決於「被限流擋下」算不算 Day 17 ③ 節表格裡「進 availability 分母」的事件。本文範例把它留給讀者依產品契約決定,但至少要在 SLO spec 裡寫清楚:這個 SLI 的分母,是所有到達 ASGI app 的 request,還是只到達 path operation 的 request。這兩句話看起來很像,量出來的分母可能差一截。
FastAPI 允許用 @app.exception_handler() 註冊自訂例外處理器,這一層通常在 middleware 之後、response 產生之前執行。實務上常見的錯誤順序是:handler 拋出自訂例外 → exception handler 把它轉成 JSON response → SLI middleware 的 except Exception 分支永遠抓不到,因為例外早就被攔截掉了,middleware 看到的只是一個正常返回的 response。
如果團隊同時使用了自訂 exception handler 與本文的 record_request_sli,就必須確認 technical_status 的傳遞路徑仍然完整——request.state 在 exception handler 裡一樣可以設定,但如果團隊忘記在 exception handler 裡補上這一步,middleware 的 getattr(request.state, "technical_status", ...) 只會回退到用 response.status_code < 500 猜測,一旦例外處理器把所有例外都轉成 200(例如刻意讓 client 端不用處理多種錯誤格式),這個猜測會直接失準,所有例外都被誤記成 success。
(上)把使用者句子拆成三個候選 SLI、寫成可被反駁的規格,並在 FastAPI middleware 與 handler 裡把事件記下來。(下)要把這些事件轉成真正能查的 SLI、用 DIY 步驟驗證規格跟程式碼行為對不對得上,再列出常見的失敗模式與交給值班者的一頁說明。
這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.